# 字節對編碼標記化

<CourseFloatingBanner chapter={6}
  classNames="absolute z-10 right-0 top-0"
  notebooks={[
    {label: "Google Colab", value: "https://colab.research.google.com/github/huggingface/notebooks/blob/master/course/zh-CN/chapter6/section5.ipynb"},
    {label: "Aws Studio", value: "https://studiolab.sagemaker.aws/import/github/huggingface/notebooks/blob/master/course/zh-CN/chapter6/section5.ipynb"},
]} />

字節對編碼(BPE)最初被開發為一種壓縮文本的算法,然後在預訓練 GPT 模型時被 OpenAI 用於標記化。許多 Transformer 模型都使用它,包括 GPT、GPT-2、RoBERTa、BART 和 DeBERTa。

<Youtube id="HEikzVL-lZU"/>

> [!TIP]
> 💡 本節深入介紹了BPE，甚至展示了一個完整的實現。如果你只想大致瞭解標記化算法，可以跳到最後。

## 訓練算法

BPE 訓練首先計算語料庫中使用的唯一單詞集(在完成標準化和預標記化步驟之後)，然後通過獲取用於編寫這些單詞的所有符號來構建詞彙表。舉一個簡單的例子，假設我們的語料庫使用了這五個詞:

```
"hug", "pug", "pun", "bun", "hugs"
```

基礎詞彙將是 `["b", "g", "h", "n", "p", "s", "u"]`。對於實際情況，基本詞彙表將包含所有 ASCII 字符，至少，可能還包含一些 Unicode 字符。如果您正在標記的示例使用不在訓練語料庫中的字符，則該字符將轉換為未知標記。這就是為什麼許多 NLP 模型在分析帶有表情符號的內容方面非常糟糕的原因之一。

> [!TIP]
> TGPT-2 和 RoBERTa 標記器(非常相似)有一個聰明的方法來處理這個問題: 他們不把單詞看成是用 Unicode 字符寫的，而是用字節寫的。這樣，基本詞彙表的大小很小(256),但你能想到的每個字符仍將被包含在內,而不會最終轉換為未知標記。這個技巧被稱為 *字節級 BPE*。

獲得這個基本詞彙後，我們添加新的標記，直到通過學習*合併*達到所需的詞彙量，這是將現有詞彙表的兩個元素合併為一個新元素的規則。因此在開始時，這些合併將創建具有兩個字符的標記，然後隨著訓練的進行，會創建更長的子詞。

在分詞器訓練期間的任何一步，BPE 算法都會搜索最常見的現有標記對 ("對",這裡我們指的是單詞中的兩個連續標記)。最頻繁的一對將被合併，我們沖洗並重複下一步。

回到我們之前的例子，讓我們假設單詞具有以下頻率：

```
("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)
```

意味著 `"hug"` 在語料庫中出現了10次, `"pug"` 5次, `"pun"` 12次, `"bun"` 4次, 以及 `"hugs"` 5次。我們通過將每個單詞拆分為字符(形成我們初始詞彙表的字符)來開始訓練,這樣我們就可以將每個單詞視為一個標記列表:

```
("h" "u" "g", 10), ("p" "u" "g", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "u" "g" "s", 5)
```

然後我們看成對。這對 `("h", "u")` 出現在單詞 `"hug"` 和 `"hugs"`中,所以語料庫中總共有15次。不過,這並不是最頻繁的一對:這個榮譽屬於 `("u", "g")`,它出現在 `"hug"`, `"pug"`, 以及 `"hugs"`中,在詞彙表中總共 20 次。

因此,標記器學習的第一個合併規則是 `("u", "g") -> "ug"`,意思就是 `"ug"` 將被添加到詞彙表中,並且這對應該合併到語料庫的所有單詞中。在這個階段結束時,詞彙表和語料庫看起來像這樣:

```
Vocabulary: ["b", "g", "h", "n", "p", "s", "u", "ug"]
Corpus: ("h" "ug", 10), ("p" "ug", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "ug" "s", 5)
```

現在我們有一些導致標記長於兩個字符的對: 例如 `("h", "ug")`, 在語料庫中出現15次。然而,這個階段最頻繁的對是 `("u", "n")`,在語料庫中出現16次,所以學到的第二個合併規則是 `("u", "n") -> "un"`。將其添加到詞彙表併合並所有現有的這個對,將出現:

```
Vocabulary: ["b", "g", "h", "n", "p", "s", "u", "ug", "un"]
Corpus: ("h" "ug", 10), ("p" "ug", 5), ("p" "un", 12), ("b" "un", 4), ("h" "ug" "s", 5)
```

現在最頻繁的一對是 `("h", "ug")`,所以我們學習了合併規則 `("h", "ug") -> "hug"`,這給了我們第一個三個字母的標記。合併後,語料庫如下所示:

```
Vocabulary: ["b", "g", "h", "n", "p", "s", "u", "ug", "un", "hug"]
Corpus: ("hug", 10), ("p" "ug", 5), ("p" "un", 12), ("b" "un", 4), ("hug" "s", 5)
```

我們繼續這樣合併,直到達到我們所需的詞彙量。

> [!TIP]
> ✏️ **現在輪到你了!**你認為下一個合併規則是什麼？

## 標記化算法

標記化緊跟訓練過程,從某種意義上說,通過應用以下步驟對新輸入進行標記:

1. 規範化
2. 預標記化
3. 將單詞拆分為單個字符
4. 將學習的合併規則按順序應用於這些拆分

讓我們以我們在訓練期間使用的示例為例,學習三個合併規則:

```
("u", "g") -> "ug"
("u", "n") -> "un"
("h", "ug") -> "hug"
```

這個單詞 `"bug"` 將被標記為 `["b", "ug"]`。然而 `"mug"`,將被標記為 `["[UNK]", "ug"]`,因為字母 `"m"` 不再基本詞彙表中。同樣,單詞`"thug"` 會被標記為 `["[UNK]", "hug"]`: 字母 `"t"` 不在基本詞彙表中,應用合併規則首先導致 `"u"` 和 `"g"` 被合併,然後是 `"hu"` 和 `"g"` 被合併。

> [!TIP]
> ✏️ **現在輪到你了!** 你認為這個詞 `"unhug"` 將如何被標記？

## 實現 BPE

現在讓我們看一下 BPE 算法的實現。這不會是你可以在大型語料庫上實際使用的優化版本;我們只是想向你展示代碼,以便你可以更好地理解算法

首先我們需要一個語料庫,所以讓我們用幾句話創建一個簡單的語料庫:

```python
corpus = [
    "This is the Hugging Face course.",
    "This chapter is about tokenization.",
    "This section shows several tokenizer algorithms.",
    "Hopefully, you will be able to understand how they are trained and generate tokens.",
]
```

接下來,我們需要將該語料庫預先標記為單詞。由於我們正在複製 BPE 標記器(如 GPT-2),我們將使用 `gpt2` 標記器作為預標記化的標記器:

```python
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("gpt2")
```

然後我們在進行預標記化時計算語料庫中每個單詞的頻率:

```python
from collections import defaultdict

word_freqs = defaultdict(int)

for text in corpus:
    words_with_offsets = tokenizer.backend_tokenizer.pre_tokenizer.pre_tokenize_str(text)
    new_words = [word for word, offset in words_with_offsets]
    for word in new_words:
        word_freqs[word] += 1

print(word_freqs)
```

```python out
defaultdict(int, {'This': 3, 'Ġis': 2, 'Ġthe': 1, 'ĠHugging': 1, 'ĠFace': 1, 'ĠCourse': 1, '.': 4, 'Ġchapter': 1,
    'Ġabout': 1, 'Ġtokenization': 1, 'Ġsection': 1, 'Ġshows': 1, 'Ġseveral': 1, 'Ġtokenizer': 1, 'Ġalgorithms': 1,
    'Hopefully': 1, ',': 1, 'Ġyou': 1, 'Ġwill': 1, 'Ġbe': 1, 'Ġable': 1, 'Ġto': 1, 'Ġunderstand': 1, 'Ġhow': 1,
    'Ġthey': 1, 'Ġare': 1, 'Ġtrained': 1, 'Ġand': 1, 'Ġgenerate': 1, 'Ġtokens': 1})
```

下一步是計算基本詞彙,由語料庫中使用的所有字符組成:

```python
alphabet = []

for word in word_freqs.keys():
    for letter in word:
        if letter not in alphabet:
            alphabet.append(letter)
alphabet.sort()

print(alphabet)
```

```python out
[ ',', '.', 'C', 'F', 'H', 'T', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'k', 'l', 'm', 'n', 'o', 'p', 'r', 's',
  't', 'u', 'v', 'w', 'y', 'z', 'Ġ']
```

我們還在該詞彙表的開頭添加了模型使用的特殊標記。對於GPT-2,唯一的特殊標記是 `"<|endoftext|>"`:

```python
vocab = ["<|endoftext|>"] + alphabet.copy()
```

我們現在需要將每個單詞拆分為單獨的字符,以便能夠開始訓練:

```python
splits = {word: [c for c in word] for word in word_freqs.keys()}
```

現在我們已準備好進行訓練,讓我們編寫一個函數來計算每對的頻率。我們需要在訓練的每個步驟中使用它:

```python
def compute_pair_freqs(splits):
    pair_freqs = defaultdict(int)
    for word, freq in word_freqs.items():
        split = splits[word]
        if len(split) == 1:
            continue
        for i in range(len(split) - 1):
            pair = (split[i], split[i + 1])
            pair_freqs[pair] += freq
    return pair_freqs
```

讓我們來看看這個字典在初始拆分後的一部分:

```python
pair_freqs = compute_pair_freqs(splits)

for i, key in enumerate(pair_freqs.keys()):
    print(f"{key}: {pair_freqs[key]}")
    if i >= 5:
        break
```

```python out
('T', 'h'): 3
('h', 'i'): 3
('i', 's'): 5
('Ġ', 'i'): 2
('Ġ', 't'): 7
('t', 'h'): 3
```

現在, 找到最頻繁的對只需要一個快速的循環:

```python
best_pair = ""
max_freq = None

for pair, freq in pair_freqs.items():
    if max_freq is None or max_freq < freq:
        best_pair = pair
        max_freq = freq

print(best_pair, max_freq)
```

```python out
('Ġ', 't') 7
```

所以第一個要學習的合併是 `('Ġ', 't') -> 'Ġt'`, 我們添加 `'Ġt'` 到詞彙表:

```python
merges = {("Ġ", "t"): "Ġt"}
vocab.append("Ġt")
```

要繼續接下來的步驟,我們需要在我們的`分詞`字典中應用該合併。讓我們為此編寫另一個函數:

```python
def merge_pair(a, b, splits):
    for word in word_freqs:
        split = splits[word]
        if len(split) == 1:
            continue

        i = 0
        while i < len(split) - 1:
            if split[i] == a and split[i + 1] == b:
                split = split[:i] + [a + b] + split[i + 2 :]
            else:
                i += 1
        splits[word] = split
    return splits
```

我們可以看看第一次合併的結果:

```py
splits = merge_pair("Ġ", "t", splits)
print(splits["Ġtrained"])
```

```python out
['Ġt', 'r', 'a', 'i', 'n', 'e', 'd']
```

現在我們有了循環所需的一切,直到我們學會了我們想要的所有合併。我們的目標是詞彙量達到50:

```python
vocab_size = 50

while len(vocab) < vocab_size:
    pair_freqs = compute_pair_freqs(splits)
    best_pair = ""
    max_freq = None
    for pair, freq in pair_freqs.items():
        if max_freq is None or max_freq < freq:
            best_pair = pair
            max_freq = freq
    splits = merge_pair(*best_pair, splits)
    merges[best_pair] = best_pair[0] + best_pair[1]
    vocab.append(best_pair[0] + best_pair[1])
```

結果,我們學習了 19 條合併規則(初始詞彙表的大小 31 -- 30 字母字符,加上特殊標記):

```py
print(merges)
```

```python out
{('Ġ', 't'): 'Ġt', ('i', 's'): 'is', ('e', 'r'): 'er', ('Ġ', 'a'): 'Ġa', ('Ġt', 'o'): 'Ġto', ('e', 'n'): 'en',
 ('T', 'h'): 'Th', ('Th', 'is'): 'This', ('o', 'u'): 'ou', ('s', 'e'): 'se', ('Ġto', 'k'): 'Ġtok',
 ('Ġtok', 'en'): 'Ġtoken', ('n', 'd'): 'nd', ('Ġ', 'is'): 'Ġis', ('Ġt', 'h'): 'Ġth', ('Ġth', 'e'): 'Ġthe',
 ('i', 'n'): 'in', ('Ġa', 'b'): 'Ġab', ('Ġtoken', 'i'): 'Ġtokeni'}
```

詞彙表由特殊標記、初始字母和所有合併結果組成:

```py
print(vocab)
```

```python out
['<|endoftext|>', ',', '.', 'C', 'F', 'H', 'T', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'k', 'l', 'm', 'n', 'o',
 'p', 'r', 's', 't', 'u', 'v', 'w', 'y', 'z', 'Ġ', 'Ġt', 'is', 'er', 'Ġa', 'Ġto', 'en', 'Th', 'This', 'ou', 'se',
 'Ġtok', 'Ġtoken', 'nd', 'Ġis', 'Ġth', 'Ġthe', 'in', 'Ġab', 'Ġtokeni']
```

> [!TIP]
> 💡 在同一語料庫上使用 `train_new_from_iterator()` 不會產生完全相同的詞彙表。這是因為當有最頻繁對的選擇時,我們選擇遇到的第一個, 而 🤗 Tokenizers 庫根據內部ID選擇第一個。

為了對新文本進行分詞,我們對其進行預分詞、拆分，然後應用學到的所有合併規則:

```python
def tokenize(text):
    pre_tokenize_result = tokenizer._tokenizer.pre_tokenizer.pre_tokenize_str(text)
    pre_tokenized_text = [word for word, offset in pre_tokenize_result]
    splits = [[l for l in word] for word in pre_tokenized_text]
    for pair, merge in merges.items():
        for idx, split in enumerate(splits):
            i = 0
            while i < len(split) - 1:
                if split[i] == pair[0] and split[i + 1] == pair[1]:
                    split = split[:i] + [merge] + split[i + 2 :]
                else:
                    i += 1
            splits[idx] = split

    return sum(splits, [])
```

我們可以在任何由字母表中的字符組成的文本上嘗試這個:

```py
tokenize("This is not a token.")
```

```python out
['This', 'Ġis', 'Ġ', 'n', 'o', 't', 'Ġa', 'Ġtoken', '.']
```

> [!WARNING]
> ⚠️ 如果存在未知字符,我們的實現將拋出錯誤,因為我們沒有做任何處理它們。GPT-2 實際上沒有未知標記(使用字節級 BPE 時不可能得到未知字符),但這可能發生在這裡,因為我們沒有在初始詞彙表中包含所有可能的字節。 BPE 的這方面超出了本節的範圍,因此我們忽略了細節。

這就是 BPE 算法！接下來,我們將看看 WordPiece。